Мы работаем в стартапе, который продаёт продукты питания. Нужно разобраться, как ведут себя пользователи нашего мобильного приложения.
Для этого изучим воронку продаж. Узнаем, как пользователи доходят до покупки. Сколько пользователей доходит до покупки, а сколько — «застревает» на предыдущих шагах.
Также мы исследуем результаты A/A/B-эксперимента. Дизайнеры захотели поменять шрифты во всём приложении, а менеджеры испугались, что пользователям будет непривычно. Договорились принять решение по результатам A/A/B-теста. Пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми.
Поэтому наша цель — провести исследование поведения пользователей приложения и определить, влияет ли внедрение нового шрифта на продажи.
import pandas as pd
import numpy as np
from scipy import stats as st
from io import BytesIO
import math as mth
import requests
import warnings
warnings.simplefilter('ignore')
import datetime as dt
from datetime import datetime, timedelta
import plotly.express as px
from plotly import graph_objects as go
try:
logs = pd.read_csv('logs_exp.csv', sep='\t')
except:
logs = pd.read_csv('/datasets/logs_exp.csv', sep='\t')
def first_look (df: pd.DataFrame):
'''Функция получения первичной информации о датафрейме'''
print ('------------- Первые 5 строк ------------')
display(df.sample(5))
print('')
print ('------------- Типы данных ------------')
df.info()
print('')
print ('------------- Описание ------------')
print('')
display(df.describe().T)
print ('------------- Пропуски ------------')
count = 0
shape_0 = df.shape[0]
for element in df.columns:
if df[element].isna().sum() > 0:
(print(element, ' - ', df[element].isna().sum(),
'пропусков, ',
round(df[element].isna().sum() * 100 / shape_0,2),
'% от числа строк.'))
count = +1
if count == 0:
print('Пропусков НЕТ')
print('')
print('')
print ('------------- Дубликаты ------------')
if df.duplicated().sum() > 0:
print('Дубликатов: ', df.duplicated().sum())
else:
print('Дубликатов НЕТ')
first_look(logs)
------------- Первые 5 строк ------------
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 140836 | MainScreenAppear | 5508964131129122209 | 1564959281 | 246 |
| 62519 | MainScreenAppear | 8540811162439173094 | 1564758711 | 246 |
| 65398 | MainScreenAppear | 3072483068986271209 | 1564762933 | 248 |
| 28170 | OffersScreenAppear | 5193326445430429508 | 1564675680 | 248 |
| 149308 | MainScreenAppear | 6376227609029980568 | 1564995445 | 248 |
------------- Типы данных ------------ <class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB ------------- Описание ------------
| count | mean | std | min | 25% | 50% | 75% | max | |
|---|---|---|---|---|---|---|---|---|
| DeviceIDHash | 244126.0 | 4.627568e+18 | 2.642425e+18 | 6.888747e+15 | 2.372212e+18 | 4.623192e+18 | 6.932517e+18 | 9.222603e+18 |
| EventTimestamp | 244126.0 | 1.564914e+09 | 1.771343e+05 | 1.564030e+09 | 1.564757e+09 | 1.564919e+09 | 1.565075e+09 | 1.565213e+09 |
| ExpId | 244126.0 | 2.470223e+02 | 8.244339e-01 | 2.460000e+02 | 2.460000e+02 | 2.470000e+02 | 2.480000e+02 | 2.480000e+02 |
------------- Пропуски ------------ ------------- Дубликаты ------------ Дубликатов: 413
Нам даны следующие данные:
Каждая запись в логе — это действие пользователя, или событие.
В данных присутствуют дубликаты - удалим их на следующем этапе. Пропусков в данных нет.
Для удобства также изменим названия столбцов, а также исправим формат даты и времени.
Заменим названия столбцов на удобные для нас.
logs.columns = ['event_name', 'user_id', 'event_timestamp', 'exp_group']
logs.head(6)
| event_name | user_id | event_timestamp | exp_group | |
|---|---|---|---|---|
| 0 | MainScreenAppear | 4575588528974610257 | 1564029816 | 246 |
| 1 | MainScreenAppear | 7416695313311560658 | 1564053102 | 246 |
| 2 | PaymentScreenSuccessful | 3518123091307005509 | 1564054127 | 248 |
| 3 | CartScreenAppear | 3518123091307005509 | 1564054127 | 248 |
| 4 | PaymentScreenSuccessful | 6217807653094995999 | 1564055322 | 248 |
| 5 | CartScreenAppear | 6217807653094995999 | 1564055323 | 248 |
Избавимся от дубликатов:
logs = logs.drop_duplicates()
Исправим форматы времени - добавим новые столбцы "дата и время" и "дата".
logs['event_datetime'] = pd.to_datetime(logs['event_timestamp'], unit='s')
logs['event_date'] = logs['event_datetime'].astype('datetime64[D]')
Мы провели подготовку данных к исследованию, теперь изучим содержание.
logs.shape
(243713, 6)
В имеющихся у нас данных всего 243713 событий.
logs['user_id'].nunique()
7551
При этом уникальных пользователей 7,5 тысяч.
len(logs['user_id'])/logs['user_id'].nunique()
32.27559263673685
В среднем на каждого пользователя приходится 32 события.
print('Минимальная дата:',logs['event_date'].min())
print('Максимальная дата:',logs['event_date'].max())
Минимальная дата: 2019-07-25 00:00:00 Максимальная дата: 2019-08-07 00:00:00
fig = px.histogram(logs, x='event_datetime',
title='Гистограмма событий по дате и времени.',
labels={'event_datetime':'дата события'},
height=400)
fig.show();
На графике ярко выражены периоды роста и спада активности пользоваталей. Между тем, можно явно сказать, что данные до 01 августа 2019 года - неполные и могут быть искажены.
Поэтому для нашего исследования мы оставим данные за период с первого по седьмое августа.
logs = logs[logs['event_date'] > '2019-07-31'].reset_index()
logs.head()
| index | event_name | user_id | event_timestamp | exp_group | event_datetime | event_date | |
|---|---|---|---|---|---|---|---|
| 0 | 2828 | Tutorial | 3737462046622621720 | 1564618048 | 246 | 2019-08-01 00:07:28 | 2019-08-01 |
| 1 | 2829 | MainScreenAppear | 3737462046622621720 | 1564618080 | 246 | 2019-08-01 00:08:00 | 2019-08-01 |
| 2 | 2830 | MainScreenAppear | 3737462046622621720 | 1564618135 | 246 | 2019-08-01 00:08:55 | 2019-08-01 |
| 3 | 2831 | OffersScreenAppear | 3737462046622621720 | 1564618138 | 246 | 2019-08-01 00:08:58 | 2019-08-01 |
| 4 | 2832 | MainScreenAppear | 1433840883824088890 | 1564618139 | 247 | 2019-08-01 00:08:59 | 2019-08-01 |
print('Удалено строк: {}'.format(243713 - len(logs['event_name'])))
print('Потеряно событий: {:.2%}'.format(1-(len(logs['event_name'])/243713)))
print('Потеряно пользователей: {:.2%}'.format(1-(logs['user_id'].nunique()/7551)))
Удалено строк: 2826 Потеряно событий: 1.16% Потеряно пользователей: 0.23%
Потери незначительны, значит, фильтрация данных по времени не повлияет на результаты анализа.
logs['exp_group'].unique()
array([246, 247, 248])
Все три группы на месте.
Вывод:
Мы изучили данные в нашей таблице, определили, что в среднем на каждого пользователя приходится 32 события. Установили неполноту и искажение данных в период до 1 августа 2019 года и отфильтровали датафрейм по дате, проверив при этом долю потерянных данных.
events = (logs.groupby('event_name')
.agg({'user_id':'count'})
.sort_values(by='user_id', ascending=False)
.reset_index())
events
| event_name | user_id | |
|---|---|---|
| 0 | MainScreenAppear | 117328 |
| 1 | OffersScreenAppear | 46333 |
| 2 | CartScreenAppear | 42303 |
| 3 | PaymentScreenSuccessful | 33918 |
| 4 | Tutorial | 1005 |
В нашем распоряжении всего 5 видов событий:
Отсортируем события по числу пользователей.
actions= (logs.groupby('event_name')
.agg({'user_id':'nunique'})
.sort_values(by='user_id', ascending=False)
.reset_index())
actions['rate'] = round(actions['user_id']/logs['user_id'].nunique()*100, 2)
actions
| event_name | user_id | rate | |
|---|---|---|---|
| 0 | MainScreenAppear | 7419 | 98.47 |
| 1 | OffersScreenAppear | 4593 | 60.96 |
| 2 | CartScreenAppear | 3734 | 49.56 |
| 3 | PaymentScreenSuccessful | 3539 | 46.97 |
| 4 | Tutorial | 840 | 11.15 |
Как следует из количества событий по открытию пользователями руководства, эта страница не обязательна для входа в приложение и не входит в воронку событий.
Удалим это событие из будущих рассчетов.
logs_short = logs[logs['event_name'] !='Tutorial']
ex_actions= (logs_short.groupby(['event_name','exp_group'])
.agg({'user_id':'nunique'})
.sort_values(by='user_id', ascending=False)
.reset_index())
fig = px.funnel(ex_actions, x='user_id', y='event_name', color='exp_group', text='user_id',
labels={'event_name':'события', 'exp_group':'экс. группа'})
fig.update_traces( textinfo = "value+percent previous", textposition='inside')
fig.update_layout(title_text='Воронка событий по трем группам (процент перехода от предыдущего события)')
fig.show()
На шаге "OffersScreenAppear", посещение пользователями страницы с каталогом товаров, теряется максимум - почти 40% - пользователей.
Наибольший процент конверсии на последнем этапе - из страницы корзины к успешной оплате - 95%.
Построим еще одну воронку, которая покажет процент перехода на следующий этап не от предыдущего шага, а от общего числа пользователей группы.
logs_short = logs[logs['event_name'] !='Tutorial']
ex_actions= (logs_short.groupby(['event_name','exp_group'])
.agg({'user_id':'nunique'})
.sort_values(by='user_id', ascending=False)
.reset_index())
fig = px.funnel(ex_actions, x='user_id', y='event_name', color='exp_group', text='user_id',
labels={'event_name':'события', 'exp_group':'экс. группа'})
fig.update_traces( textinfo = "value+percent initial", textposition='inside')
fig.update_layout(title_text='Воронка событий по трем группам (процент перехода от 100%)')
fig.show()
Из графика мы видим, что в среднем 47.7% пользователей от общего числа пользователей каждой группы успешно завершают покупку.
Вывод:
В нашем распоряжении всего 5 видов событий:
На шаге "OffersScreenAppear", посещение пользователями страницы с каталогом товаров, теряется максимум - почти 40% - пользователей. Наибольший процент конверсии на последнем этапе - из страницы корзины к успешной оплате - 95%.
Также, если считать долю успешных покупок от числа пользователей, зашедших в приложение, можно установить, что в среднем 47.7% пользователей от общего числа пользователей каждой группы успешно завершают покупку.
В нашем распоржении две контрольные А группы - 246 и 247.
Это добавит уверенности в точности проведенного тестирования. Если же между значениями A и A будут существенные различия, это поможет обнаружить факторы, которые привели к искажению результатов. Сравнение контрольных групп также помогает понять, сколько времени и данных потребуется для дальнейших тестов.
users_amount = (logs.groupby('exp_group')
.agg({'user_id':'nunique'})
.sort_values(by='user_id', ascending=False)
.reset_index())
users_amount
| exp_group | user_id | |
|---|---|---|
| 0 | 248 | 2537 |
| 1 | 247 | 2513 |
| 2 | 246 | 2484 |
fig = go.Figure(data=[go.Pie(labels=users_amount['exp_group'],
values=users_amount['user_id'], textinfo='label+percent', textposition='outside')])
fig.update_layout(title_text='Cоотношение экспериментальных групп по количеству пользователей')
fig.show()
Все три экспериментальные группы группы практически равны и содержат примерно 2,5 тысячи уникальных пользователей.
Проверим уникальность пользователей каждой группы.
user_per_group = (logs.groupby('user_id')
.agg({'exp_group':'nunique'}))
print('Количество пользователей, попавших в несколько групп:',
user_per_group[user_per_group['exp_group']>1].count())
print('Процент пользователей, попавших в несколько групп:',
user_per_group[user_per_group['exp_group']>1].count()/len(user_per_group))
Количество пользователей, попавших в несколько групп: exp_group 0 dtype: int64 Процент пользователей, попавших в несколько групп: exp_group 0.0 dtype: float64
Выше мы уже посчитали общее количество пользователей в каждой группе.
Теперь посчитаем количество покупателей в каждой группе.
buyers_246 = (logs.query('exp_group ==246 & event_name=="PaymentScreenSuccessful"').groupby('exp_group')
.agg({'user_id':'nunique'})
.sort_values(by='user_id', ascending=False)
.reset_index())
buyers_246
| exp_group | user_id | |
|---|---|---|
| 0 | 246 | 1200 |
buyers_247 = (logs.query('exp_group ==247 & event_name=="PaymentScreenSuccessful"').groupby('exp_group')
.agg({'user_id':'nunique'})
.sort_values(by='user_id', ascending=False)
.reset_index())
buyers_247
| exp_group | user_id | |
|---|---|---|
| 0 | 247 | 1158 |
Сформируем нулевую и альтернативную гипотезы:
Нулевая гипотеза: Статистически значимых различий между конверсиями перехода пользоваталей между событиями воронки у групп нет.
Альтернативная гипотеза: Статистически значимые различия между конверсиями перехода пользоваталей между событиями воронки у групп есть.
Проведем проверку гипотез:
alpha = 0.05
buyers = np.array([buyers_246['user_id'], buyers_247['user_id']])
users = np.array([2484, 2513])
p1 = buyers[0]/users[0]
p2 = buyers[1]/users[1]
p_combined = (buyers[0] + buyers[1]) / (users[0] + users[1])
# разница пропорций в датасетах
difference = abs(p1 - p2)
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/users[0] + 1/users[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
z_value = difference / mth.sqrt(
p_combined * (1 - p_combined) * (1 / users[0] + 1 / users[1])
)
distr = st.norm(0, 1)
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('p-значение: ', p_value)
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между группами есть статистически значимая разница в конверсии')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми'
)
p-значение: [0.11456679] Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми
Нам не удалось отвергнуть нулевую гипотезу при проведении А/А-теста, значит, расчеты работают корректно.
events
| event_name | user_id | |
|---|---|---|
| 0 | MainScreenAppear | 117328 |
| 1 | OffersScreenAppear | 46333 |
| 2 | CartScreenAppear | 42303 |
| 3 | PaymentScreenSuccessful | 33918 |
| 4 | Tutorial | 1005 |
Самым популярным событием, очевидно, является первый шаг - посещение главной страницы приложения.
Рассчитаем количество пользователей в этом и остальных событиях в каждой из групп - это поможет нам в дальшейших рассчетах. Добавим также объединенную контрольную группу.
group_events = (logs_short.pivot_table(index='event_name', columns='exp_group',
values='user_id',aggfunc='nunique')
.sort_values(by=246, ascending=False))
group_events['246+247'] = group_events[246] + group_events[247]
group_events
| exp_group | 246 | 247 | 248 | 246+247 |
|---|---|---|---|---|
| event_name | ||||
| MainScreenAppear | 2450 | 2476 | 2493 | 4926 |
| OffersScreenAppear | 1542 | 1520 | 1531 | 3062 |
| CartScreenAppear | 1266 | 1238 | 1230 | 2504 |
| PaymentScreenSuccessful | 1200 | 1158 | 1181 | 2358 |
Для оптимизации процессов, создадим функцию проверки гипотез для каждого действия внутри двух сравниваемых групп по вышеуказанному образцу.
Для начала, добавим в нашу талицу с количеством пользователей в каждой экспериментальной группе данные по объединенной контрольной группе.
users_amount.loc[3] = ['246+247', 4997]
users_amount = users_amount.set_index(users_amount.columns[0])
def testing(event_name, exp_group1, exp_group2, alpha):
buyers = np.array([group_events.loc[event_name, exp_group1], group_events.loc[event_name, exp_group2]])
users = np.array([users_amount.loc[exp_group1, 'user_id'], users_amount.loc[exp_group2, 'user_id']])
p1 = buyers[0]/users[0]
p2 = buyers[1]/users[1]
p_combined = (buyers[0] + buyers[1]) / (users[0] + users[1])
# разница пропорций в датасетах
difference = abs(p1 - p2)
z_value = difference / mth.sqrt(p_combined * (1 - p_combined) * (1/users[0] + 1/users[1]))
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
z_value = difference / mth.sqrt(
p_combined * (1 - p_combined) * (1 / users[0] + 1 / users[1])
)
distr = st.norm(0, 1)
# задаем стандартное нормальное распределение (среднее 0, ст.отклонение 1)
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print(('Проверка гипотезы для групп {} и {}, событие: {}, p_value: {}'.format(exp_group1, exp_group2,
event_name, p_value)))
if p_value < alpha:
print('Отвергаем нулевую гипотезу: между группами есть статистически значимая разница в конверсии')
else:
print(
'Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми'
)
for event_name in group_events.index:
testing(event_name, 246, 247, 0.05)
print()
Проверка гипотезы для групп 246 и 247, событие: MainScreenAppear, p_value: 0.7570597232046099 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 246 и 247, событие: OffersScreenAppear, p_value: 0.2480954578522181 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 246 и 247, событие: CartScreenAppear, p_value: 0.22883372237997213 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 246 и 247, событие: PaymentScreenSuccessful, p_value: 0.11456679313141849 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми
Для всех событий результат один - нет оснований считать различия между группами статистически значимыми. Значит, разбивка по группам прошла корректно.
При этом уточним, что сейчас мы проведем проверку между 3-мя парами групп в отношении 4 событий. Следовательно, мы проведем 12 тестов. Во избежание ошибок при проверкет гипотез, примем поправку Бонферрони, и разделим уровень значимости на количество тестов. Поэтому альфа будет равен 0.004.
for event_name in group_events.index:
testing(event_name, 246, 248, 0.004)
print()
Проверка гипотезы для групп 246 и 248, событие: MainScreenAppear, p_value: 0.2949721933554552 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 246 и 248, событие: OffersScreenAppear, p_value: 0.20836205402738917 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 246 и 248, событие: CartScreenAppear, p_value: 0.07842923237520116 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 246 и 248, событие: PaymentScreenSuccessful, p_value: 0.2122553275697796 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми
for event_name in group_events.index:
testing(event_name, 247, 248, 0.004)
print()
Проверка гипотезы для групп 247 и 248, событие: MainScreenAppear, p_value: 0.4587053616621515 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 247 и 248, событие: OffersScreenAppear, p_value: 0.9197817830592261 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 247 и 248, событие: CartScreenAppear, p_value: 0.5786197879539783 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 247 и 248, событие: PaymentScreenSuccessful, p_value: 0.7373415053803964 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми
for event_name in group_events.index:
testing(event_name, '246+247', 248, 0.004)
print()
Проверка гипотезы для групп 246+247 и 248, событие: MainScreenAppear, p_value: 0.29424526837179577 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 246+247 и 248, событие: OffersScreenAppear, p_value: 0.43425549655188256 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 246+247 и 248, событие: CartScreenAppear, p_value: 0.18175875284404386 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми Проверка гипотезы для групп 246+247 и 248, событие: PaymentScreenSuccessful, p_value: 0.6004294282308704 Не получилось отвергнуть нулевую гипотезу, нет оснований считать различия в конверсии между группами статистически значимыми
Вывод: Мы разделили уровень статистической значимости на количество тестов во избежание ошибок, при этом по результатам теста p-значение практически ни разу не опустилось даже ниже 0.1. Однако уровень значимости, равный 0.1 в данном эксперименте нельзя использовать, поскольку с учетом проведения 12 тестов, вероятность ошибки будет 71%
Ни в одним из тестов не была отвергнута нулевая гипотеза, следовательно, даже если между группами есть различия, они не являются статистически значимыми. Значит, гипотеза о веротности влияния нового шрифта в приложении на продаж не подтвердилась.
Мы провели исследование поведения пользователей приложения и определили, влияет ли внедрение нового шрифта на продажи. В нашем распоряжении были данных о 5 видах событий в приложении :
Мы установили, что шаге "OffersScreenAppear", посещение пользователями страницы с каталогом товаров, теряется максимум - почти 40% - пользователей. Наибольший процент конверсии на последнем этапе - из страницы корзины к успешной оплате - 95%.
Также, если считать долю успешных покупок от числа пользователей, зашедших в приложение, можно установить, что в среднем 47.7% пользователей от общего числа пользователей каждой группы успешно завершают покупку.
В нашем распоржении было две контрольные А группы - 246 и 247. Если бы между значениями A и A будут существенные различия, это помогло бы обнаружить факторы, которые привели к искажению результатов. По результатам сравнения контрольных групп мы определили, что между группами нет статистически значимых различий.
Мы фактически проводили 12 тестов, поэтому приняли поправку Бонферрони и разделили уровень статистической значимости на количество тестов во избежание ошибок.
В результате проверки гипотез мы установили, что ни одним из тестов не была отвергнута нулевая гипотеза, следовательно, даже если между группами есть различия, они не являются статистически значимыми. Отсюда следует вывод, что гипотеза о веротности влияния нового шрифта в приложении на продажи не подтвердилась.